fix: propagate re-registered node id and make rebind thread-safe#94
fix: propagate re-registered node id and make rebind thread-safe#94kaiitunnz wants to merge 9 commits into
Conversation
Split the decode/type-filter body out of iter_pubsub_messages so a polling reader (get_message) and the blocking reader (listen) share one frame parser. Behavior of iter_pubsub_messages is unchanged. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The dispatch and command subscriptions were re-subscribed directly from the heartbeat thread while the reader thread was inside pubsub.listen(). A redis-py PubSub is not safe to mutate from a second thread, and the health-check PINGs on that connection make interleaved socket writes reachable. rebind() now only records the target id under a lock; the reader polls with get_message and applies the subscribe/unsubscribe itself. wait_rebound lets the caller order work after the switch. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
rebind_node ran on the heartbeat thread and mutated _node_id plus the in-process registry while RegisterWorker ran on the grpc loop, unlocked; a worker registering mid-rebind could be homed on the stale node. Guard _node_id and the register/re-home window with a lock, skip workers whose Redis record was evicted rather than resurrecting a partial record, and pipeline the rewrites so the lock spans two round trips, not N. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
A re-register mints a fresh node id inside the child, but the parent only read it once via the startup handshake, leaving app.state.node_id (request auth scope) and the EventMonitor's own-node (shutdown correlation) stale. The child now re-puts the new id on the handshake queue and a parent watcher thread refreshes the cached id and notifies listeners. The put is non-blocking so it cannot stall the heartbeat thread. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Update the re-register tests to the pending-rebind contract (rebind records the target and touches no pubsub; the reader-side apply moves the subscription) and add the cross-thread regression guard, the worker re-home exists guard, parse_pubsub_message decoding, and the parent node-id watcher/listener path. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
The two-pipeline existence-check-then-HSET could recreate a partial worker record if the key was deleted between the checks. Do the conditional rewrite in a single Lua EVAL so a worker deleted mid-rebind is skipped, not resurrected. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Register the re-register callback once the dispatch/command reader threads are running, so a re-register cannot block on wait_rebound waiting for a reader that has not started yet. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
…client Tighten the docstrings/comments added by the re-register work, group the node-id watcher methods under their own section, and add the async twin of SyncRedisClient.eval to keep the two clients at parity. No behavior change. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
Extract the StubRegistry/StubLifecycle test doubles into a shared supervisor_helpers module, consolidate the duplicated _reregister_if_lost tests into the lifecycle suite, and build the fakes via real constructors / subclasses so no test needs a type: ignore. Signed-off-by: Noppanat Wadlom <noppanat.wad@gmail.com>
timzsu
left a comment
There was a problem hiding this comment.
Two correctness-related comments, plus several comments on the clean-up of pre-existing typing issues.
| # These constants are Linux-only; skip any the running platform lacks (e.g. | ||
| # macOS/Windows dev hosts) so importing this module there doesn't fail. |
There was a problem hiding this comment.
Do we plan to support macOS/Windows? I thought Linux is our only target platform
| task_listener.wait_rebound(_REBIND_APPLY_TIMEOUT_SEC) | ||
| command_listener.wait_rebound(_REBIND_APPLY_TIMEOUT_SEC) |
There was a problem hiding this comment.
If the wait times out, then the server is rebound to the new ID without a proper Redis channel configured. I think we should capture the results of the wait, and if they are False, we should retry properly.
| # the heartbeat thread, which must not stall on a full queue). | ||
| try: | ||
| node_id_queue.put_nowait(new_node_id) | ||
| except QueueFull: |
There was a problem hiding this comment.
How about draining the oldest item from the queue if the queue is full?
| return value[7:] # type: ignore | ||
| if key_l == "x-worker-token": | ||
| return value # type: ignore |
There was a problem hiding this comment.
I think we should remove these two type: ignores, which were from the first commit.
| def _get_worker_from_context( | ||
| self, context: grpc.aio.ServicerContext | ||
| ) -> WorkerAdapter | None: | ||
| token = _token_from_metadata(context.invocation_metadata()) # type: ignore |
There was a problem hiding this comment.
Similarly, I think this type: ignore can also be fixed easily.
| def _get_worker_id_from_context( | ||
| self, context: grpc.aio.ServicerContext | ||
| ) -> str | None: | ||
| token = _token_from_metadata(context.invocation_metadata()) # type: ignore |
|
|
||
|
|
||
| def _sync[T](value: Awaitable[T] | T) -> T: | ||
| return value # type: ignore |
There was a problem hiding this comment.
Can we remove this # type: ignore?
| try: | ||
| rm = ResourceManager.get_instance() | ||
| max_gpu_count = rm.total_gpu_count | ||
| current_gpu_count_getter = lambda: rm.available_gpu_count # noqa: E731 |
There was a problem hiding this comment.
I think this noqa is also not justified.
Purpose
Follow-up to #91 / #93. #91 makes a node whose root-registry record is lost re-register under a fresh, server-allocated node id, and #93 rebinds the child-side subscriptions and re-homes workers. Two correctness gaps remained: the rebind mutated a live redis-py
PubSubfrom the heartbeat thread while the reader thread was insidelisten()(unsafe cross-thread access, made reachable by #92's health-check PINGs on that connection), and the parent server process kept stale copies of the node id after re-registration. This PR closes both while keeping the id server-allocated (no reuse).Changes
src/server/supervisor/services/task_listener.py,command_listener.py—rebindnow records the target node id under a lock; the pubsub reader thread applies thesubscribe/unsubscribebetweenget_messagepolls and signals completion via an event, so noPubSubis ever mutated off the reader thread.src/server/clients/redis.py— extractparse_pubsub_messageso the blocking and polling readers share one frame parser, and add anevalwrapper (sync + async) for the re-home script.src/server/supervisor/services/grpc_server.py— guardrebind_nodeand theRegisterWorkernode-id/registry critical section with a lock, and re-home workers with a single conditional-HSETLuaEVALthat skips (rather than resurrects) workers whose record was evicted.src/server/supervisor/supervisor.py,src/server/main.py— the child re-puts the new id on the handshake queue (non-blocking) and a parent watcher thread refreshesapp.state.node_idand the EventMonitor's own-node through listeners.tests/server/— reader-owned rebind, worker re-home,parse_pubsub_message, and parent node-id watcher coverage, with shared re-register stubs insupervisor_helpers.py.Design
The id stays server-allocated and fresh on each re-register — reusing the old id was rejected because a wiped root resets
NODE_ID_SEQ(collision risk) and accepting a client-chosen id weakens the allocation invariant. The new id is instead propagated coherently: subscriptions move on the thread that owns the socket, and the parent learns the id over the existing multiprocessing handshake queue rather than the telemetry stream (the queue is in-process and reliable; telemetry is exactly what intermediaries cull, per #92)._on_reregisterrebinds the subscriptions and waits for the reader to apply them before re-homing workers, so the dispatcher never publishes to a channel nobody is listening on yet.Test Plan
End-to-end on a local root + a CPU worker on a freshly built image carrying this branch: delete the node's control-plane keys to simulate the root registry losing it, then confirm the node re-registers under a new id within one heartbeat interval and stays functional — the new id answers node commands and a workflow submitted afterward runs to DONE (dispatch rebind + worker re-home).
Test Result
End-to-end: after the node's keys were deleted it re-registered
nde-1→nde-2within one heartbeat interval;node worker list <new_id>answered on the new command channel (no hang), and a workflow submitted after re-register ran to DONE with the worker observed homed under the new id.Pre-submission Checklist
pre-commit run --all-filesand fixed any issues.uv run pytest tests/passes locally.[BREAKING]and described migration steps above.